import type { API } from "./types";

export const TYPE_MESSAGE = 'message';
export const TYPE_RESPONSE = 'response';
export const TYPE_SET_INTERFACE = 'set-interface';
export const TYPE_SERVICE_MESSAGE = 'service-message';

// @ts-expect-error this is IE11 obsolete check. It is not typed
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;

const defaultOptions = {
    //Will not affect IE11 because there sandboxed iframe has not 'null' origin
    //but base URL of iframe's src
    allowedSenderOrigin: undefined
};

export interface ConnectionOptions {
    allowedSenderOrigin?: string;
}

class Connection {
    incrementalID: number;
    options: ConnectionOptions;
    postMessage: typeof window.postMessage;
    remote: API = {};
    serviceMethods: API = {};
    localApi: API = {};
    callbacks: {[key: string]: {successCallback: Function, failureCallback: Function}} = {};
    remoteMethodsWaitPromise: Promise<void>;
    _resolveRemoteMethodsPromise: null | (() => void) = null;

    constructor(
        postMessage: typeof window.postMessage,
        registerOnMessageListener: (listener: (e: MessageEvent) => void) => void,
        options: ConnectionOptions = {}
    ) {
        this.options = { ...defaultOptions, ...options };
        //Random number between 0 and 100000
        this.incrementalID = Math.floor(Math.random() * 100000);

        this.postMessage = postMessage;

        this.remoteMethodsWaitPromise = new Promise(resolve => {
            this._resolveRemoteMethodsPromise = resolve;
        });

        registerOnMessageListener((e: MessageEvent) => this.onMessageListener(e));
    }

    /**
     * Listens to remote messages. Calls local method if it is called outside or call stored callback if it is response.
     * @param e - onMessage event
     */
    onMessageListener(e: MessageEvent) {
        const data = e.data;

        const { allowedSenderOrigin } = this.options;
        if (allowedSenderOrigin && e.origin !== allowedSenderOrigin && !isIE11) {
            return;
        }

        if (data.type === TYPE_RESPONSE) {
            this.popCallback(data.callId, data.success, data.result);
        } else if (data.type === TYPE_MESSAGE) {
            this
                .callLocalApi(data.methodName, data.arguments)
                .then(res => this.responseOtherSide(data.callId, res))
                .catch(err => this.responseOtherSide(data.callId, err, false));
        } else if (data.type === TYPE_SET_INTERFACE) {
            this.setInterface(data.apiMethods);
            this.responseOtherSide(data.callId);
        } else if (data.type === TYPE_SERVICE_MESSAGE) {
            this
                .callLocalServiceMethod(data.methodName, data.arguments)
                .then(res => this.responseOtherSide(data.callId, res))
                .catch(err => this.responseOtherSide(data.callId, err, false));
        }
    }

    postMessageToOtherSide(dataToPost: any) {
        this.postMessage(dataToPost, '*');
    }

    /**
     * Sets remote interface methods
     * @param remote - hash with keys of remote API methods. Values is ignored
     */
    setInterface(remoteMethods: API) {
        this.remote = {};

        remoteMethods.forEach(
            (key: string) => this.remote[key] = this.createMethodWrapper(key)
        );

        this._resolveRemoteMethodsPromise?.();
    }

    setLocalApi(api: API) {
        return new Promise((resolve, reject) => {
            const id = this.registerCallback(resolve, reject);
            this.postMessageToOtherSide({
                callId: id,
                apiMethods: Object.keys(api),
                type: TYPE_SET_INTERFACE
            });
        }).then(() => this.localApi = api);
    }

    setServiceMethods(api: API) {
        this.serviceMethods = api;
    }

    /**
     * Calls local method
     * @param methodName
     * @param args
     * @returns {Promise.<*>|string}
     */
    callLocalApi(methodName: string, args: any[]) {
        return Promise.resolve(this.localApi[methodName](...args));
    }

    /**
     * Calls local method registered as "service method"
     * @param methodName
     * @param args
     * @returns {Promise.<*>}
     */
    callLocalServiceMethod(methodName: string, args: any[]) {
        if (!this.serviceMethods[methodName]) {
            throw new Error(`Serivce method ${methodName} is not registered`);
        }
        return Promise.resolve(this.serviceMethods[methodName](...args));
    }

    /**
     * Wraps remote method with callback storing code
     * @param methodName - method to wrap
     * @returns {Function} - function to call as remote API interface
     */
    createMethodWrapper(methodName: string) {
        return (...args: any[]) => {
            return this.callRemoteMethod(methodName, ...args);
        };
    }

    /**
     * Calls other side with arguments provided
     * @param id
     * @param methodName
     * @param args
     */
    callRemoteMethod(methodName: string, ...args: any[]) {
        return new Promise((resolve, reject) => {
            const id = this.registerCallback(resolve, reject);
            this.postMessageToOtherSide({
                callId: id,
                methodName: methodName,
                type: TYPE_MESSAGE,
                arguments: args
            });
        });
    }

    /**
     * Calls remote service method
     * @param methodName
     * @param args
     * @returns {*}
     */
    callRemoteServiceMethod(methodName: string, ...args: any[]) {
        return new Promise((resolve, reject) => {
            const id = this.registerCallback(resolve, reject);
            this.postMessageToOtherSide({
                callId: id,
                methodName: methodName,
                type: TYPE_SERVICE_MESSAGE,
                arguments: args
            });
        });
    }

    /**
     * Respond to remote call
     * @param id - remote call ID
     * @param result - result to pass to calling function
     */
    responseOtherSide(id: string, result?: any, success = true) {
        if (result instanceof Error) {
            // Error could be non-serializable, so we copy properties manually
            result = [ ...Object.keys(result), 'message' ].reduce((acc, it) => {
                acc[it] = result[it];
                return acc;
            }, {} as {[k: string]: any});
        }

        const doPost = () =>
            this.postMessage(
                {
                    callId: id,
                    type: TYPE_RESPONSE,
                    success,
                    result
                },
                '*'
            );

        try {
            doPost();
        } catch (err) {
            console.error('Failed to post response, recovering...', err); // eslint-disable-line no-console
            if (err instanceof DOMException) {
                result = JSON.parse(JSON.stringify(result));
                doPost();
            }
        }
    }

    /*
       * Stores callbacks to call later when remote call will be answered
       */
    registerCallback(successCallback: Function, failureCallback: Function) {
        const id = (++this.incrementalID).toString();
        this.callbacks[id] = { successCallback, failureCallback };
        return id;
    }

    /**
     * Calls and delete stored callback
     * @param id - call id
     * @param success - was call successful
     * @param result - result of remote call
     */
    popCallback(id: string, success: boolean, result: any) {
        if (success) {
            this.callbacks[id].successCallback(result);
        } else {
            this.callbacks[id].failureCallback(result);
        }
        delete this.callbacks[id];
    }
}

export default Connection;
